Skip to content

feat(cms-mode): host the CMS block editor in the side panel - #6145

Closed
tlgimenes wants to merge 21 commits into
mainfrom
claude/sharded-mapping-starfish
Closed

feat(cms-mode): host the CMS block editor in the side panel#6145
tlgimenes wants to merge 21 commits into
mainfrom
claude/sharded-mapping-starfish

Conversation

@tlgimenes

@tlgimenes tlgimenes commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Two things, and the second one is why the first became interesting.

1. CMS mode gets a real home. Fast Preview projects rendered an inert chat panel — a "coming soon" notice — while the block editor was squeezed into a 31% pane nested inside the Preview tab. Three columns, and the widest one did nothing. The side panel now hosts the CMS editor and the preview canvas takes the whole main panel. Fast Preview is renamed CMS mode throughout.

2. The gate becomes per-branch, which makes the two modes composable instead of exclusive. resolveCmsMode(metadata).active short-circuited the entire sandbox proxy to runner: null for every branch of a CMS project — so a CMS project could never boot a pod, and vibecoding was unreachable. The gate now splits: resolveCmsMode is the project capability, and the new resolveCmsModeForBranch narrows it with "does this branch have a sandbox?". Provisioning one moves that branch onto the daemon while its siblings stay sandbox-less.

Sandbox-only projects are unchanged.

Why per-branch is the whole design

The two editors write to different layers of the same branch:

CMS write   → patchDecofile → GitHub API → a commit on the branch head
Vibe write  → POST /sandbox/…/write     → a file in the pod's working tree

Gating on the project gives a branch two writers: the CMS committing to a head that the pod — holding uncommitted work — can no longer see. The agent's next commit then clobbers or conflicts.

Gating on the branch gives it exactly one. Every CMS hook already had both substrates wired (use-save-block, use-delete-block, use-decofile, use-section-preview-base each branch on cmsModeActive), so following the branch means a CMS edit on a pod-backed draft lands in the working tree next to the agent's edits. There is no divergence left to warn about — the fix is deleting a wrong condition, not adding a reconciliation UI.

The switch

  • Sandbox-less draft → the chat composer offers "Start coding" (SANDBOX_START) instead of a locked input.
  • Draft with a pod → both CMS and Chat toggles, not one or the other.
  • Not one-way: the CMS panel keeps working, now writing through the pod.

Notable

  • SidePanelKind widens to "chat" | "cms" across all six sites that hardcoded the literal. Three failed silently — the router zod schema rejected ?sidepanel=cms, and thread-layout memory + chat navigation dropped it — so the panel would have vanished on navigation with no error.
  • Retiring the nested pane needed more than emptying it. defaultSize applies at mount, so gating only its content left a 210px dead column. It has to not render.
  • The CMS tour anchor moves to the new toggle — that anchor is the tour's readiness gate, so leaving it on a removed button would have stopped the tour starting.
  • The branch flips at the click, not when the metadata refetch lands: cmsModeActive also counts a pending start and the seeded preview URL. In that window the pod is already cloning the head, so a CMS write routed there could miss the clone and vanish silently; routed to the not-yet-reachable sandbox it fails visibly.
  • hasVmForBranch is kind-agnostic on purpose, and branchHasSandbox falls back to the thread row — a thread-scoped branch records its pod there, and missing it would hand a pod-backed branch back to the head-committing path.

Deliberate: the write path still writes the legacy key

resolveCmsMode reads cmsMode first and falls back to fastPreview, but writers still write fastPreview. Flipping the write before every reader ships would 404 the CMS for any newly-toggled project, and the metadata object is .loose(), so a premature cmsMode write type-checks and fails only against a real server. An e2e proves cmsMode alone opens the gate.

Testing

fmt · check · lint · knip clean.

  • New e2e: cms-mode-branch-gate.spec.ts — asserts over HTTP that git/status is GitHub-backed until a sandbox is recorded for the branch, that recording one takes that branch off the GitHub path, and that its sibling is unaffected. A project-level gate fails the third assertion.
  • The CMS-project fixture is promoted out of decofile-api.spec.ts into fixtures/cms-project.ts, so both suites share one definition. (This was listed as a follow-up on an earlier revision of this PR; it's done.)
  • 12/12 pass for both specs against a clean Postgres locally.
  • New unit tests cover resolveCmsMode and resolveCmsModeForBranch.
  • Unit-test counts baselined against a stashed pristine tree: identical failures with and without these changes (environmental — cross-file DOM-setup collisions), plus the new passes.

Verified live in the native app: toggle renders as CMS, panel mounts with the real section list, ?sidepanel=cms survives a full reload, nested pane and old Edit content toggle both gone.

Known gaps

  • The mode transition has not been clicked through by a human. It's covered by the e2e above and typechecks, but nobody has watched CMS draft → Start coding → tabs/console/chat appear → CMS panel still editing through the pod. Worth doing before merge.
  • header-actions.tsx carries ~100 lines of dead CMS handling. VirtualMcpHeaderInfo has routed CMS projects to CmsHeaderActions since feat(fast-preview): smart primary action button for CMS mode #6054, so those branches were already unreachable before this PR. Left alone rather than widening the diff.
  • sandbox-events-context.tsx still reads the project-level gate for its blocks-save reload heuristic. Cosmetic only (the decofile refetch inside is separately gated on the dev server), and the legacy semantics there predate the sandbox-less rework.

🤖 Generated with Claude Code


Summary by cubic

Hosts the CMS block editor in the side panel and renames Fast Preview to CMS mode. The CMS/Vibecoding switch now governs the entire workspace and gates per branch, so one draft can start vibecoding without affecting siblings.

  • Consolidates gating in packages/shared as resolveCmsMode (capability) and resolveCmsModeForBranch (per-branch runtime). API routes (decofile, sandbox-proxy) and web read the same rule; writers still persist the legacy fastPreview key.
  • Replaces paired toggles with a split button (CMS | Vibecoding). The body collapses/reopens the current mode; the caret selects the mode. Selection persists as ?mode=cms|vibecoding and is remembered per thread; CMS projects default the side panel to CMS.
  • Widens SidePanelKind to "chat" | "cms" and sanitizes untrusted values with parseSidePanelKind across router search, layout memory, task switch, and chat navigation.
  • Retires the inline blocks pane on CMS projects via resolveEffectiveEditingMode; CMS editing lives in the side panel in both modes. Section previews use buildCmsDraftUrl; preview display uses cmsModeActive/cmsModeReady.
  • Mode changes update preview origin, view tabs, and console together. CMS hides Code when no sandbox exists; the bottom drawer mounts only with a sandbox and manages its own collapsed/expanded state (terminal visibility prefs and provider removed).
  • Chat on sandbox‑less drafts offers “Start vibecoding” and flips the per‑branch gate immediately; once a sandbox exists the CMS panel keeps working and writes through the pod. The blocks panel warns when the pod has uncommitted work (countLocalWork).
  • Tests: new e2e cms-mode-branch-gate.spec.ts; shared CMS fixtures; unit tests for gates, editing‑mode downgrades, preview display; side‑panel toggles; layout memory and task‑switch restore; sandbox git‑API counts.

Migration

  • Replace @/sdk/fast-preview with @/sdk/cms-mode; rename buildFastPreviewDraftUrl to buildCmsDraftUrl; update resolveSectionPreviewBase to accept cmsModeActive.
  • Accept and persist "cms" in side‑panel state; use parseSidePanelKind for untrusted input. Persist the selected mode via the mode query param (cms | vibecoding) and restore it per thread.
  • Use resolveCmsModeForBranch for runtime gating and resolveCmsMode for capability checks (API and web).
  • Remove terminal visibility preferences, overrides, and any TerminalVisibilityProvider usage; do not assume the Code tab or console exists on sandbox‑less branches.
  • Update copy to “CMS mode” and adopt the split‑button switch (grid mark for CMS, code mark for vibecoding).

Written for commit b2d8dd3. Summary will update on new commits.

Review in cubic

Fast Preview projects rendered an inert chat panel — a "coming soon"
notice — while the block editor was squeezed into a 31% pane nested
inside the Preview tab. The side panel now hosts the editor and the
preview canvas takes the whole main panel.

Renames Fast Preview to CMS mode throughout, and consolidates the gate:
`resolveCmsMode` in packages/shared is now the single reader, consumed by
the web app and by both API gates (`decofile.ts`, `sandbox-proxy.ts`)
that previously read `metadata.fastPreview` directly. `preview.tsx` also
inlined its own copy; it now calls the helper.

The persisted key is deliberately still written as `fastPreview` —
`resolveCmsMode` reads `cmsMode` first and falls back to it. Flipping the
write before every reader ships would 404 the CMS for any newly-toggled
project, since the decofile API gates on it.

- SidePanelKind widens to "chat" | "cms", including the six sites that
  hardcoded the literal. Three of those failed silently rather than at
  compile time (router zod schema, thread-layout memory, chat
  navigation) and would have dropped the CMS panel on navigation.
- The nested blocks pane and its Edit content toggle are retired in CMS
  mode only. Emptying the pane was not enough: `defaultSize` applies at
  mount, so it left a 210px dead column.
- The CMS tour anchor moves from the preview toolbar to the new toggle.
- Sandbox projects are unchanged.

Verified against a live CMS-mode project in the native app: toggle,
panel mount, section list, `?sidepanel=cms` surviving a full reload, and
both the nested pane and old toggle gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the claude PR authored by a coding agent label Aug 17, 2026
CMS mode has no chat to start, so the relocated new-chat button in the
panel header was offering a dead action. The sidebar still exposes it
when expanded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tlgimenes
tlgimenes force-pushed the claude/sharded-mapping-starfish branch from f2865f9 to a31cbe2 Compare August 17, 2026 12:20
tlgimenes and others added 19 commits August 17, 2026 09:53
The console rendered in CMS mode. Three places decided its visibility and
none agreed: the provider derived `visible` from a preference and a
per-VM localStorage override, `preview.tsx` hid the ⋯ menu item when CMS
mode was on, and MainPanelWithDrawer gated the drawer on
`hasClonableSource` — true for a CMS project, which has a repo.

So a default-on preference (or a stale per-VM override) kept the drawer
mounted, while the only control that could dismiss it was hidden.

Move the condition to where the state lives: the provider takes
`available` and forces `visible` false when there is no daemon to attach
to, so an override cannot resurrect it. Consumers read `available`
instead of re-deriving the gate — which removes the CMS branch from
preview.tsx rather than adding a fourth one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… collapse

Terminal visibility was two stacked persisted booleans: "does the drawer
exist" (a preference plus a per-VM override, behind the ⋯ menu) and "is
it expanded" (PreviewDrawerHost's own open/height, per VM). The first
layer only existed to hide a surface the second layer could already
collapse — and hiding it removed the very control that dismissed it.

Now the drawer mounts whenever the project can have one and sits
collapsed to its toolbar until expanded. Collapsed it renders the toolbar
only: no xterm instances, and no new connections, since the lifecycle and
events contexts are already mounted at the shell.

Removes TerminalVisibilityProvider, the terminalVisibleByDefault
preference and its Settings row, the per-VM localStorage override with
its parser and tests, the ⋯ Show/Hide item, and four i18n keys in both
dictionaries.

Net -223 lines, and the class of bug this replaces — a hidden control
stranding a visible surface — is now unrepresentable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… tab

Two more surfaces assumed a sandbox. The Content tab bailed to
SandboxStateRenderer because `previewState.kind !== "iframe"`, then
treated every phase before `running` as warming — so CMS mode, whose
phase never leaves "idle", showed "cloning your repo" forever. The tab
itself was gated the same way: `devServerReady` fed the decofile fetch,
so `hasEditableDecoContent` never saw data.

`resolveBlocksTabState` already documents the rule — "the lifecycle phase
stays idle forever and must not gate the panel; data readiness alone
decides" — it just was not applied here. Both now bypass the lifecycle in
CMS mode and read the decofile over HTTP.

Also splits the Preview/Code pair `getSourceSystemTabs` shipped as one
unit. Code browses the sandbox filesystem, which CMS mode does not have,
so CMS mode gets Preview + Content and vibecoding keeps Preview + Code.

Verified on a live CMS project: Content loads 677 pages, 10 sections, 25
loaders; the tab bar reads Preview · Content with no Code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tour gated eligibility on `lifecycle.phase === "running"`, which CMS
mode never reaches — so the walkthrough built to teach the CMS only ever
ran on sandbox projects.

Readiness now also accepts CMS mode, where the surface is up immediately.
The launch path already polls for the lead anchor and filters steps to
visible ones, so the sandbox-less case degrades on its own: four steps
resolve in CMS mode (Preview, page dropdown, CMS toggle, branches) and
the visual-editor step drops out, since it needs an origin we can inject
into.

Verified on a live CMS project: previewReady true with phase "idle", four
visible steps, tour marks itself seen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The spec and plan were working notes for this change, not documentation
the repo should carry — and they had already drifted from what shipped.
Their content stays in this branch's history.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CMS mode was a project-level either/or: `resolveCmsMode(metadata).active`
short-circuited the whole sandbox proxy to `runner: null` for every branch, so
a CMS project could never boot a pod and vibecoding was unreachable.

Split the gate in two. `resolveCmsMode` stays the project capability; the new
`resolveCmsModeForBranch` narrows it with "does this branch have a sandbox?"
and is what every runtime surface now reads. Provisioning a sandbox moves that
one branch onto the daemon while its siblings stay sandbox-less.

That keeps exactly one writer per branch. The CMS hooks already had both
substrates wired — decofile API vs `/sandbox/write`, preview server vs dev
server — so following the branch means a CMS edit on a pod-backed draft lands
in the working tree next to the agent's edits, instead of committing to a head
the pod can no longer see. No divergence to warn about.

The switch itself: the chat composer on a sandbox-less draft now offers "Start
coding" (SANDBOX_START) instead of a locked input, and a CMS project with a pod
shows both the CMS and Chat toggles rather than one or the other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oding button

The gate change had no black-box coverage. Adds cms-mode-branch-gate.spec.ts,
which asserts over HTTP that a CMS project's `git/status` is GitHub-backed until
a sandbox is recorded for that branch, that recording one takes THAT branch off
the GitHub path, and that its sibling branch is unaffected — the per-branch half
of the contract, which a project-level gate would fail.

Promotes the CMS-project fixture (and the GitHub stub admin helpers) out of
decofile-api.spec.ts into fixtures/cms-project.ts so both suites share one
definition instead of the second copying it.

Two fixes found while verifying:

- `StartCodingState` disabled itself on `status !== "idle"`, but the live
  provider never yields "idle" — `computeDrawerStatus` maps a branch with no
  preview URL to "starting", which is every sandbox-less branch. The button was
  permanently disabled. Replaced with `isStarting`, the start mutation's own
  pending flag, now exposed on the lifecycle context.

- The per-branch signal now also counts a pending start and the seeded preview
  URL, so the branch leaves the sandbox-less path at the CLICK rather than when
  the metadata refetch lands. In that window the pod is already cloning the
  branch head, so a CMS write routed to the head could miss the clone and be
  lost silently; routed to the not-yet-reachable sandbox it fails visibly.

Verified: 12/12 of both e2e specs on a clean Postgres.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e you edit

The switch had no handle. `sidePanelToggles` gated the Chat half on
`!cmsModeActive`, so on a sandbox-less CMS draft — exactly the drafts that need
to switch — it never rendered. The "Start coding" prompt sat in a side panel
with no button to open it, reachable only by hand-editing `?sidepanel=`.

A CMS project now shows BOTH halves in every branch state, as a `CMS | Code`
pair. Only the Code half's behaviour varies: on a sandbox-less draft it
provisions the dev environment and opens the agent, so the modes are one click
apart in both directions. Non-CMS projects keep the single Chat toggle.

The rule moves into `resolveSidePanelToggles` with a test asserting that
provisioning a sandbox never adds or removes a toggle — the invariant this bug
broke, and the second toggle-visibility regression on this branch.

Adds the one advisory the per-branch model still warrants. Once a draft has a
dev environment the CMS panel keeps working but its writes stop being commits
on the branch head and become files in the pod's working tree, next to the
agent's uncommitted work; a note in the panel says so. A sandbox-less draft
gets nothing — the default needs no announcement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The preview toolbar's Edit action and its nested pane were gated per branch, so
a CMS draft that gained a sandbox got BOTH editors: the side panel's BlocksPanel
and a second copy of the same component inside the Preview tab, each with its
own selection state. The toolbar button also sat in the middle of the preview
while the real switch lives on the left.

Both now gate on the PROJECT (`resolveCmsMode(...).active`), not the branch. The
side panel owns content editing in CMS and vibecoding alike, so provisioning a
sandbox no longer resurrects the inline pane. Projects with no CMS keep it as
their only way in — and, with the pair mutually exclusive, `TOUR_ANCHORS.edit`
again resolves to exactly one element per project type.

The downgrade rule moves into `resolveEffectiveEditingMode` with tests, so
"blocks never opens inline on a CMS project, in either mode" is asserted rather
than trusted to a condition in JSX.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The divider that sets the Edit action off from the view controls rendered
unconditionally, so a CMS project — which has no Edit action, its block editor
being the left-hand toggle — was left with a stray vertical rule leading the
URL group.

The mobile layout's empty left zone stays: both side zones are `flex-1` so the
page selector centres on the bar, and dropping the zone would shift it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…g-starfish

# Conflicts:
#	apps/web/src/i18n/en/agent-shell-layout.ts
#	apps/web/src/i18n/pt-br/agent-shell-layout.ts
#	apps/web/src/layouts/agent-shell-layout/toggle-buttons.tsx
#	apps/web/src/layouts/agent-shell-layout/workspace-panel-group.tsx
Two tab buttons read as two features rather than two modes of one draft, and
neither had anywhere to say that vibecoding must first be provisioned. They
collapse into a single SplitButton:

  body  → collapse or reopen the side panel, on the mode it already shows
  caret → choose the mode

The division follows how often each is used. Collapsing is a many-times-a-
session action and takes the wide target — which is also what the tab did, so
nothing is relearned. Choosing a mode happens about once per draft and sits
behind a caret, the only thing a caret has ever meant. On a sandbox-less draft
the vibecoding entry reads "Start vibecoding · builds a dev environment" and
provisions on select, so the cost is stated before it is paid.

`mode` is threaded in rather than read off `sidePanel`, which goes null on
close: the body must reopen the occupant the user left, so WorkspacePanelGroup
remembers it. It lives there and not in the button because the button unmounts
as it relocates between the chat and main headers.

`SplitButtonMenuItem` gains `description` and `selected` — a check in a reserved
gutter and a secondary line — plus a `dataTour` passthrough so the CMS tour can
still anchor on the control. All three are additive and optional.

`resolveSidePanelToggles` drops from two half-flags to one `mode`, keeping the
test that provisioning a sandbox never adds or removes a control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`PuzzlePiece01` said "plugin", not "content". `Grid01` — four separate rounded
squares in a 2x2 — reads as a page of blocks, which is what the CMS edits, and
it is the mark the mode switch was designed against.

Swapped in both places the concept appears: the mode split button, and the
preview toolbar's Edit action, which is how projects WITHOUT a CMS reach the
same inline block editor. Leaving one behind would have split the visual
language across two doors into the same surface.

Vibecoding keeps `Code01`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The menu named the two modes but only the button carried their icons, so the
list read as text while the control it belongs to did not. Each entry now leads
with its mark — the four-square grid for CMS, the chevrons for vibecoding.

The optical fix lives in SplitButton, not the caller: a `description` stacks the
row, so the check and the icon get a half-step down to align with the label's
line instead of the block's centre. Callers pass a plain sized icon and the
component places it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ghost left the two halves floating with nothing joining them, so the control
read as a label beside a stray caret. Outline gives them a shared border, and
ButtonGroup drops the seam between — the divider the primary already draws is
then the only line inside, which is what makes a split button read as one
control rather than two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lapsed

Collapsing the panel moves the switch into the main header, where the cluster's
2px gap put an outlined control flush against the borderless Preview/Content
tabs — one run of buttons, when the switch is a different kind of control from
the views beside it.

Scoped to that position rather than baked into the button: in the chat header
it leads the row and needs nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…panel

Picking CMS on a draft that had started vibecoding only moved the side panel:
the preview kept serving the dev server, the Code tab and console stayed, and
the mode read as one-way even though the control offered both directions.

The gate now takes the selected mode alongside the substrate. A branch with no
sandbox is CMS regardless — there is nothing else to be — and a branch with one
follows `?mode=`, so switching back restores the preview origin, the tabs and
the console together.

Mode is its own URL param rather than something read off `?sidepanel=`, which
forgets it whenever the panel closes. It now governs the workspace, so it has
to outlive the panel that set it, and survive a reload.

The deliberate cost: a CMS write commits to the branch head while the pod still
holds an uncommitted tree it cannot see, and the agent's next commit resolves
that one way or the other. Chosen over the alternative of pinning the preview to
the pod. The advisory is what makes it honest — the CMS panel warns whenever the
pod has uncommitted work, naming the count. It can see that work because the API
keeps gating on the substrate, so `/git/status` stays daemon-backed even while
the UI is in CMS mode.

`countLocalWork` joins `hasPublishableLocalWork` behind one shared path list, so
the banner's count and its trigger cannot disagree; it dedupes paths that sit in
several git buckets at once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`?mode` was new URL state that the per-thread layout memory did not know about,
so leaving a vibecoding draft and coming back reopened it in CMS — silently, the
same shape as the six `SidePanelKind` literal sites that dropped the panel kind.
It matters more here: mode decides the preview's origin, the view tabs and the
console, so the wrong one restores the wrong workspace.

`ThreadLayout` now carries it, saved on the way out and restored on the way in.
`sanitizeThreadLayout` drops anything that is not a known mode — sessionStorage
is tamperable and an unknown value must read as "no memory", not reach the gate.

Tests cover the round trip and the absent case, since nothing about this failure
is visible: no error, just the other mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tlgimenes tlgimenes closed this Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

claude PR authored by a coding agent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant